Skip to content

Dpx 693 pr 2 teach lstk update to handle the bundled extensions - #482

Open
carillan81 wants to merge 4 commits into
mainfrom
dpx-693-pr-2-teach-lstk-update-to-handle-the-bundle
Open

Dpx 693 pr 2 teach lstk update to handle the bundled extensions#482
carillan81 wants to merge 4 commits into
mainfrom
dpx-693-pr-2-teach-lstk-update-to-handle-the-bundle

Conversation

@carillan81

@carillan81 carillan81 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Motivation

Once releases ship the bundled extensions (bundled-extensions and lstk-extensions.toml next to lstk), lstk update on the binary channel has to replace all three files together, not just the binary. Today it replaces one file, lstk, and ignores everything else in the archive.

That has a second-order problem. The first bundling release is installed by the updater users already have, which only replaces lstk. They land on a current binary with no extensions, and because they are already on the newest version, lstk update says "already up to date" until the next release ships. Nobody can fix that retroactively, so the new updater has to detect and repair it.

Solution

Set-wise replacement (stage-then-commit). Every member of the archive is copied into the install directory under a .lstk-new name; only when all copies succeed is each renamed over its final name, lstk last. A file visible under its real name is never half-written, a failure before the last rename leaves a working lstk to re-run with, and the updater never deletes an lstk-* file it cannot prove it owns (additive-only). An archive containing only lstk behaves exactly as before, so pre-bundling releases and rollbacks are unaffected.

Repair when current but incomplete. The release stamps the expected set into the binary (version.bundledSet, via ldflags). When versions match but a stamped member is missing or unusable, lstk update reinstalls the same release, then re-checks; if the archive still did not deliver the members it fails loudly ("did not restore the bundled extensions") instead of looping. Empty stamp keeps today's pure version comparison. Homebrew and npm never repair; the package manager owns the whole set there. The passive update notice also nudges an incomplete install toward lstk update.

The same-version repair from the first revision was dropped in review. The transition release (old updater, new archive) leaves the bundle uninstalled until the next lstk update. Instead of repairing, lstk tells the user: a release build that finds no bundle beside itself adds a reinstall hint to the unknown-command error and warns on an up-to-date lstk update (also in the --json warnings array). One goreleaser ldflag, one detector, two call sites.

Hardening. Windows moves every existing member aside before renaming (a running extension no longer breaks the commit); a squatting symlink or directory at a staging path is refused rather than written through; the install directory is listed literally instead of globbed (a [ in the path used to fail every update); setuid/setgid bits survive the update; a concurrent update cannot truncate another's staging file.

Structured output. UpdateCheckedEvent gains RepairBundled; the --json check shape gains "repairBundled": true for a same-version repair (absent otherwise, and scrubbed from the applied shape).

Dependencies and follow-ups

  • dpx-692 (PR 1) must merge first or together: it carries the resolver that dispatches bundled commands from the toml. Without it a repaired install has the files but nothing resolves them.
  • The bundledSet ldflags stamp (tasks.md 5.2) is not yet in .goreleaser.yaml; until it lands on the packaging side the repair path stays dormant and this PR changes nothing user-visible.
  • Once both branches are in, alias bundledBinaryBaseName from extension.BundledBinaryName (TODO left on the constant).

Testing

  • Unit: internal/update covers every behaviour above, including the Windows path from any host via a goos parameter. All new behaviour was written test-first.
  • Integration: transition repair, complete-set no-op, pre-bundling release unaffected, broken-archive fail-loud, leftover cleanup, plus the existing mock-GitHub happy/failure paths.
  • Manual testbed against packages built by the dpx-692 pipeline (goreleaser, npm publisher): the full transition (published 0.23.0 to bundling release, repair, no-op, bundled-to-bundled upgrade, rollback, forward) passes on the binary channel and npm. Caveat: the currently published bundle predates the bundled-extensions list contract, so the release gate was run as a warning for that exercise; Homebrew was not executed.
Docs

No documentation work needed outside this PR. docs/structured-output.md is updated for the new repairBundled key, and the update guarantees are documented in the internal/update package comment. User-facing bundling docs (docs/extensions-bundling.md) live on dpx-692.

The reinstall hint is new user-facing output; the spec has a scenario for it. Release notes for the first bundling release should carry one line: if a bundled command reports "unknown command" after updating, reinstall lstk once.

Review

Human review advisable. This is the update path, the one thing a broken release cannot ship a fix for, and it adds new user-visible behaviour (the repair). Not a self-merge candidate.

Closes DPX-693

Carlos Arilla and others added 2 commits September 2, 2026 12:06
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
@carillan81 carillan81 added semver: minor docs: skip Pull request does not require documentation changes labels Sep 3, 2026
@carillan81
carillan81 marked this pull request as ready for review September 3, 2026 18:08
@carillan81
carillan81 requested review from a team and peter-smith-phd as code owners September 3, 2026 18:08

@peter-smith-phd peter-smith-phd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for implementing this, but I'm not sure we need a 2000-line solution to handle the "binary" case where we're moving from just lstk to lstk/bundled-extensions/lstk-extensions.toml. I appreciate the need to carefully replace the three files, rather than just one, but this solution feels over complicated. I was expecting closer to 100 lines of code.

In particular, do we really need the "repair" functionality? If it breaks for some reason, we already have error messages that inform the user of the problem. They should instead just uninstall/reinstall lstk and the problem will go away. My concern is that the code has become so complicated that we'll have trouble maintaining it in future.

What can we do to make this significantly less complicated, and focus on just replacing three files instead of one?

…ser.

Co-Authored-By: Claude <noreply@anthropic.com>
@carillan81

carillan81 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for implementing this, but I'm not sure we need a 2000-line solution to handle the "binary" case where we're moving from just lstk to lstk/bundled-extensions/lstk-extensions.toml. I appreciate the need to carefully replace the three files, rather than just one, but this solution feels over complicated. I was expecting closer to 100 lines of code.

In particular, do we really need the "repair" functionality? If it breaks for some reason, we already have error messages that inform the user of the problem. They should instead just uninstall/reinstall lstk and the problem will go away. My concern is that the code has become so complicated that we'll have trouble maintaining it in future.

What can we do to make this significantly less complicated, and focus on just replacing three files instead of one?

After some discussion with humans and AI I agree that the repair functionality is an overkill. We are facing a temporal issue while the users update and the repair adds some code that will be stale in a month.

I think we must address user UX so I just implemented a better error message that gives a hint for reinstall (and command) in case the command is not found. It adds some code but barely adds a few tenths of lines.

Bucket Code Comments Blank Docs / config Added Removed
Go production 247 82 24 353 29
Go tests 679 46 62 787 51
Docs (openspec spec + tasks, CLAUDE.md) 16 16 16
Build config (.goreleaser.yaml) 1 1 1
Total 926 128 86 17 1157 97

This looks a much better solution to me.

$ lstk deploy
Error: unknown command "deploy" for lstk
  This lstk release ships bundled extensions, but none are installed in <install dir>.
  ==> See help: lstk -h
  ==> Reinstall lstk: download the latest release from https://github.com/localstack/lstk/releases/latest
(exit 1)

$ lstk update
Checking for updates...
> Note: Already up to date (9.9.9-SNAPSHOT-dfd62a7)
> Warning: This lstk release ships bundled extensions, but none are installed in <install dir>. Reinstall lstk: download ...

Does this works for you?

@peter-smith-phd

Copy link
Copy Markdown
Contributor

Does this works for you?

Sorry, I ran out of time to review this today, but will look at it in more detail tomorrow. On the surface, it looks like a good improvement 😄

- **THEN** the update succeeds using the in-the-field updater (which replaces only the lstk binary and ignores the archive's extra members)
- **AND** the install is left with an incomplete set, since that updater predates bundling and cannot be made to fail
- **AND** the incomplete set is repaired by the next `lstk update`, which SHALL NOT wait for a newer release to become available
- **AND** until the next release's `lstk update` (now the set-wise updater) installs the complete set, lstk points the user at a reinstall: an unknown command and an up-to-date `lstk update` both say the release ships bundled extensions that are not installed, and name the reinstall command for the install method (accepted one-release gap; a same-version repair was considered and dropped as too much machinery in PR #482)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't feel correct. Will the following behaviour happen?

  1. I update next week (using the binary install method) which doesn't (yet) download the bundled-extensions binary too.
  2. I type lstk strt (note the typo)
  3. It suggests "you should reinstall lstk"?

Am I understanding that correctly? I would have thought that lstk would just say Error: unknown command "strt" for lstk as it does now, since reinstalling seems pretty drastic to recover from a typo. The issue of the missing extensions will automatically fix itself in the next update.

In a month or so we can change this behaviour, but it doesn't feel correct in the migration period.


// bundlesExtensions is "true" on release builds, which ship the
// bundled-extensions binary and lstk-extensions.toml beside lstk.
var bundlesExtensions = "false"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't this always be true from now on? Is there a reason that the BundlesExtensions function must be called? Why not just assume that all future releases have bundles installed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is false until we merge the other PR, just in case we merge this one first (this one could already be merged and released with lstk usual cycle)

func ReinstallCommand(m InstallMethod) string {
switch m {
case InstallHomebrew:
return "brew reinstall --cask localstack/tap/lstk"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had the impression that brew and npm would handle the "set" correctly, and we wouldn't need to detect missing files.

Comment thread .goreleaser.yaml
- -trimpath
ldflags:
- -s -w -X github.com/localstack/lstk/internal/version.version={{ .Version }}
- -s -w -X github.com/localstack/lstk/internal/version.version={{ .Version }} -X github.com/localstack/lstk/internal/version.bundlesExtensions=true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned earlier, I'm unsure why this is necessary. I don't think we plan to stop having bundles, so couldn't the code simply hard-code the fact that all lstk releases use bundled extensions.

Comment thread internal/update/update.go
// lstk commits last, so a working lstk always remains. Windows caveat: a
// crash between renaming lstk.exe aside and renaming the new one in leaves
// no lstk.exe; rename lstk.exe.old back by hand.
// 3. Nothing is deleted: an lstk-* file absent from the archive is left alone,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This third bullet point doesn't seem relevant anymore, given that we don't use lstk-* files anymore.

Comment thread internal/update/update.go
//
// An archive carrying only lstk installs exactly as before bundling existed.
//
// The pre-bundling updater installs a bundling release with only its lstk

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that I think about it - this "update" process is what should really be failing if the "bundle" isn't downloaded correctly. The whole idea of checking (DetectMissingBundle) when the user later runs a command seems a bit late. It's very unlikely that the bundle files will suddenly go missing after they're successfully installed.

}

// extractAndReplace installs every member the archive carries as one unit:
// lstk, the bundled-extensions binary, any lstk-* binaries, and the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment is out of date. We no longer have lstk-* binaries.


// replaceSet takes the platform as a parameter so the Windows naming and
// move-aside rules are testable on any host (unit tests run on Linux in CI).
func replaceSet(archivePath, exePath, format, goos string) error {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While I was reviewing this, I started to wonder why we need to do this file-by-file. Since the binary archives are literally tar.gz or .zip files, why couldn't we just extract the full archive and replace the entire directory in a single mv command. Unfortunately, I think the answer is that we don't have a well-defined way for binary-downloads to be installed, and we can't possibly know where the user decided to install lstk in the first place. This is messy, but that's what we have, so I guess the file-by-file replacement makes sense and partly explains why this PR is longer than I thought it should be 🫨

But, it did uncover the fact that completions and manpages directories are never updated. They'll always contain the original content they were downloaded with (at least, that's what Claude told me).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs: skip Pull request does not require documentation changes semver: minor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants